import fs from 'node:fs'
import path from 'node:path'

import { confirm, input, select } from '@inquirer/prompts'
import { PrismaConfigInternal } from '@prisma/config'
import { unstable_startServer } from '@prisma/dev'
import { ServerState } from '@prisma/dev/internal/state'
import type { ConnectorType } from '@prisma/generator'
import {
  arg,
  canConnectToDatabase,
  checkUnsupportedDataProxy,
  Command,
  format,
  getCommandWithExecutor,
  HelpError,
  isError,
  link,
  logger,
  PRISMA_POSTGRES_PROVIDER,
  protocolToConnectorType,
} from '@prisma/internals'
import dotenv from 'dotenv'
import { Schema as Shape } from 'effect'
import { bold, dim, green, red, yellow } from 'kleur/colors'
import ora from 'ora'
import { match, P } from 'ts-pattern'

import { FileWriter } from './init/file-writer'
import { ManagementApi, Region } from './management-api/api'
import { login } from './management-api/auth'
import { createAuthenticatedManagementApiClient } from './management-api/auth-client'
import { loadCredentials, saveCredentials } from './management-api/credentials'
import { printPpgInitOutput } from './platform/_'
import { successMessage } from './platform/_lib/messages'
import { determineClientOutputPath } from './utils/client-output-path'
import { printError } from './utils/prompt/utils/print'

/**
 * Indicates if running in Bun runtime.
 */
export const isBun: boolean =
  // @ts-ignore
  !!globalThis.Bun || !!globalThis.process?.versions?.bun

export const defaultSchema = (props?: {
  datasourceProvider?: ConnectorType
  generatorProvider?: string
  previewFeatures?: string[]
  output?: string
  withModel?: boolean
}) => {
  const {
    datasourceProvider = 'postgresql',
    generatorProvider = defaultGeneratorProvider,
    previewFeatures = defaultPreviewFeatures,
    output = '../generated/prisma',
    withModel = false,
  } = props ?? {}

  const aboutAccelerate = `\n// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init\n`

  const isProviderCompatibleWithAccelerate = datasourceProvider !== 'sqlite'

  let schema = `// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
${isProviderCompatibleWithAccelerate ? aboutAccelerate : ''}
generator client {
  provider = "${generatorProvider}"
${
  previewFeatures.length > 0
    ? `  previewFeatures = [${previewFeatures.map((feature) => `"${feature}"`).join(', ')}]\n`
    : ''
}  output   = "${output}"
}

datasource db {
  provider = "${datasourceProvider}"
}
`

  // We add a model to the schema file if the user passed the --with-model flag
  if (withModel) {
    const defaultAttributes = `email String  @unique
  name  String?`

    switch (datasourceProvider) {
      case 'mongodb':
        schema += `
model User {
  id    String  @id @default(auto()) @map("_id") @db.ObjectId
  ${defaultAttributes}
}
`
        break
      case 'cockroachdb':
        schema += `
model User {
  id    BigInt  @id @default(sequence())
  ${defaultAttributes}
}
`
        break
      default:
        schema += `
model User {
  id    Int     @id @default(autoincrement())
  ${defaultAttributes}
}
`
    }
  }

  return schema
}

export const defaultEnv = async (url: string | undefined, debug = false, comments = true) => {
  if (url === undefined) {
    let created = false
    const state =
      (await ServerState.fromServerDump({ debug })) ||
      ((created = true), await ServerState.createExclusively({ persistenceMode: 'stateful', debug }))

    if (created) {
      await state.close()
    }

    const server = await unstable_startServer({
      databasePort: state.databasePort,
      dryRun: true,
      port: state.port,
      shadowDatabasePort: state.shadowDatabasePort,
      debug,
    })

    url = server.ppg.url
  }

  let env = comments
    ? `# Environment variables declared in this file are NOT automatically loaded by Prisma.
# Please add \`import "dotenv/config";\` to your \`prisma.config.ts\` file, or use the Prisma CLI with Bun
# to load environment variables from .env files: https://pris.ly/prisma-config-env-vars.

# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
# See the documentation for all the connection string options: https://pris.ly/d/connection-strings

${
  url.startsWith('prisma+postgres:') && url.includes('localhost')
    ? `# The following \`prisma+postgres\` URL is similar to the URL produced by running a local Prisma Postgres
# server with the \`prisma dev\` CLI command, when not choosing any non-default ports or settings. The API key, unlike the
# one found in a remote Prisma Postgres URL, does not contain any sensitive information.\n\n`
    : ''
}`
    : ''
  env += `DATABASE_URL="${url}"`
  return env
}

export const defaultPort = (datasourceProvider: ConnectorType) => {
  switch (datasourceProvider) {
    case 'mysql':
      return 3306
    case 'sqlserver':
      return 1433
    case 'mongodb':
      return 27017
    case 'postgresql':
      return 5432
    case 'cockroachdb':
      return 26257
    case PRISMA_POSTGRES_PROVIDER:
      return null
  }

  return undefined
}

export const defaultURL = (
  datasourceProvider: ConnectorType,
  port = defaultPort(datasourceProvider),
  schema = 'public',
) => {
  switch (datasourceProvider) {
    case 'postgresql':
      return `postgresql://johndoe:randompassword@localhost:${port}/mydb?schema=${schema}`
    case 'cockroachdb':
      return `postgresql://johndoe:randompassword@localhost:${port}/mydb?schema=${schema}`
    case 'mysql':
      return `mysql://johndoe:randompassword@localhost:${port}/mydb`
    case 'sqlserver':
      return `sqlserver://localhost:${port};database=mydb;user=SA;password=randompassword;`
    case 'mongodb':
      return `mongodb+srv://root:randompassword@cluster0.ab1cd.mongodb.net/mydb?retryWrites=true&w=majority`
    case 'sqlite':
      return 'file:./dev.db'
    default:
      return undefined
  }
}

const defaultGitIgnore = () => {
  return `node_modules
# Keep environment variables out of version control
.env
`
}

export const defaultGeneratorProvider = 'prisma-client'

export const defaultPreviewFeatures = []

function normalizePath(configPath: string) {
  return JSON.stringify(configPath.replaceAll(path.sep, '/'))
}

export const defaultConfig = (props: { prismaFolder: string }) => {
  const { prismaFolder } = props

  const schemaPath = path.relative(process.cwd(), path.join(prismaFolder, 'schema.prisma'))
  const migrationsPath = path.relative(process.cwd(), path.join(prismaFolder, 'migrations'))

  return `\
// This file was generated by Prisma and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig, env } from "prisma/config";

export default defineConfig({
  schema: ${normalizePath(schemaPath)},
  migrations: {
    path: ${normalizePath(migrationsPath)},
  },
  datasource: {
    url: env("DATABASE_URL"),
  },
});
`
}

export class Init implements Command {
  static new(): Init {
    return new Init()
  }

  private static help = format(`
  Set up a new Prisma project

  ${bold('Usage')}

    ${dim('$')} prisma init [options]

  ${bold('Options')}

             -h, --help   Display this help message
                   --db   Provisions a fully managed Prisma Postgres database on the Prisma Data Platform.
  --datasource-provider   Define the datasource provider to use: postgresql, mysql, sqlite, sqlserver, mongodb or cockroachdb
   --generator-provider   Define the generator provider to use. Default: \`prisma-client-js\`
      --preview-feature   Define a preview feature to use.
               --output   Define Prisma Client generator output path to use.
                  --url   Define a custom datasource url

  ${bold('Flags')}

           --with-model   Add example model to created schema file

  ${bold('Examples')}

  Set up a new \`prisma dev\`-ready (local Prisma Postgres) Prisma project
    ${dim('$')} prisma init

  Set up a new Prisma project and specify MySQL as the datasource provider to use
    ${dim('$')} prisma init --datasource-provider mysql

  Set up a new \`prisma dev\`-ready (local Prisma Postgres) Prisma project and specify \`prisma-client-js\` as the generator provider to use
    ${dim('$')} prisma init --generator-provider prisma-client-js

  Set up a new \`prisma dev\`-ready (local Prisma Postgres) Prisma project and specify \`x\` and \`y\` as the preview features to use
    ${dim('$')} prisma init --preview-feature x --preview-feature y

  Set up a new \`prisma dev\`-ready (local Prisma Postgres) Prisma project and specify \`./generated-client\` as the output path to use
    ${dim('$')} prisma init --output ./generated-client

  Set up a new Prisma project and specify the url that will be used
    ${dim('$')} prisma init --url mysql://user:password@localhost:3306/mydb

  Set up a new \`prisma dev\`-ready (local Prisma Postgres) Prisma project with an example model
    ${dim('$')} prisma init --with-model
  `)

  async parse(argv: string[], _config: PrismaConfigInternal): Promise<string | Error> {
    const args = arg(argv, {
      '--help': Boolean,
      '-h': '--help',
      '--url': String,
      '--datasource-provider': String,
      '--generator-provider': String,
      '--preview-feature': [String],
      '--output': String,
      '--with-model': Boolean,
      '--db': Boolean,
      '--region': String,
      '--name': String,
      '--non-interactive': Boolean,
      '--prompt': String,
      '--vibe': String,
      '--debug': Boolean,
    })

    if (isError(args) || args['--help']) {
      return this.help()
    }

    const urlArg = args['--url']

    if (urlArg) {
      checkUnsupportedDataProxy({
        cmd: 'init',
        validatedConfig: { datasource: { url: urlArg } },
      })
    }

    /**
     * Validation
     */

    const outputDirName = args._[0]
    if (outputDirName) {
      throw Error('The init command does not take any argument.')
    }

    const { datasourceProvider, url } = await match(args)
      .with(
        {
          '--datasource-provider': P.when((datasourceProvider): datasourceProvider is string =>
            Boolean(datasourceProvider),
          ),
        },
        (input) => {
          const datasourceProvider = input['--datasource-provider'].toLowerCase()

          assertDatasourceProvider(datasourceProvider)

          const url = defaultURL(datasourceProvider)

          return { datasourceProvider, url }
        },
      )
      .with(
        {
          '--url': P.when((url): url is string => Boolean(url)),
        },
        async (input) => {
          const url = input['--url']
          const canConnect = await canConnectToDatabase(url)
          if (canConnect !== true) {
            const { code, message } = canConnect

            // P1003 means that the db doesn't exist but we can connect
            if (code !== 'P1003') {
              if (code) {
                throw new Error(`${code}: ${message}`)
              } else {
                throw new Error(message)
              }
            }
          }

          const datasourceProvider = protocolToConnectorType(`${url.split(':')[0]}:`)
          return { datasourceProvider, url }
        },
      )
      .otherwise(() => {
        return {
          datasourceProvider: 'postgresql' as const,
          url: undefined,
        }
      })
    const generatorProvider = args['--generator-provider']
    const previewFeatures = args['--preview-feature']
    const output = args['--output']
    const isPpgCommand =
      args['--db'] || datasourceProvider === PRISMA_POSTGRES_PROVIDER || args['--prompt'] || args['--vibe']

    if (args['--debug']) {
      console.log(`[isBun]`, isBun)
    }

    let prismaPostgresDatabaseUrl: string | undefined
    let workspaceId: string | undefined
    let projectId: string | undefined
    let environmentId: string | undefined

    const outputDir = process.cwd()
    const prismaFolder = path.join(outputDir, 'prisma')

    const writer = new FileWriter(outputDir)

    let generatedSchema: string | undefined
    let generatedName: string | undefined

    if (isPpgCommand) {
      const credentials = await loadCredentials()

      if (!credentials) {
        if (args['--non-interactive']) {
          return 'Please authenticate before creating a Prisma Postgres project.'
        }
        console.log('This will create a project for you on console.prisma.io and requires you to be authenticated.')
        const authAnswer = await confirm({
          message: 'Would you like to authenticate?',
        })
        if (!authAnswer) {
          return 'Project creation aborted. You need to authenticate to use Prisma Postgres'
        }
        await saveCredentials(await login({ utmMedium: 'command-init-db' }))
      }

      if (args['--prompt'] || args['--vibe']) {
        const prompt = args['--prompt'] || args['--vibe'] || ''
        const spinner = ora(`Generating a Prisma Schema based on your description ${bold(prompt)} ...`).start()

        try {
          const serverResponseShape = Shape.Struct({
            generatedSchema: Shape.String,
            generatedName: Shape.String,
          })

          ;({ generatedSchema, generatedName } = Shape.decodeUnknownSync(serverResponseShape)(
            await (
              await fetch(`https://prisma-generate-server.prisma.workers.dev/`, {
                method: 'POST',
                headers: {
                  'Content-Type': 'application/json',
                },
                body: JSON.stringify({
                  description: prompt,
                }),
              })
            ).json(),
          ))
        } catch (e) {
          spinner.fail()
          throw e
        }

        spinner.succeed('Schema is ready')
      }

      console.log("Let's set up your Prisma Postgres database!")

      const client = await createAuthenticatedManagementApiClient({ utmMedium: 'command-init-db' })
      const api = new ManagementApi(client)

      const regions = await api.getRegions()

      const ppgRegionSelection =
        (args['--region'] as Region) ||
        (await select({
          message: 'Select your region:',
          default: 'us-east-1',
          choices: regions.map((region) => ({
            name: `${region.id} - ${region.name}`,
            value: region.id as Region,
            disabled: region.status !== 'available',
          })),
          loop: true,
        }))

      const projectDisplayNameAnswer =
        args['--name'] ||
        (await input({
          message: 'Enter a project name:',
          default: generatedName || 'My Prisma Project',
        }))

      const spinner = ora(`Creating project ${bold(projectDisplayNameAnswer)} (this may take a few seconds)...`).start()

      try {
        const project = await api.createProjectWithDatabase(projectDisplayNameAnswer, ppgRegionSelection)

        if (!project.database) {
          // This should never happen: `database` should only be `null` when
          // the request body has `createDatabase: false`.
          throw new Error('Missing database info in response')
        }

        if (!project.database.directConnection) {
          // This should never happen: OpenAPI types are not entirely correct,
          // `directConnection` is not independently nullable and must always
          // be present if `database` is in the response body.
          throw new Error('Missing connection string in response')
        }

        const { host, user, pass } = project.database.directConnection
        prismaPostgresDatabaseUrl = `postgres://${user}:${pass}@${host}/postgres?sslmode=require`

        workspaceId = project.workspace.id.replace(/^wksp_/, '')
        projectId = project.id.replace(/^proj_/, '')
        environmentId = project.database.id.replace(/^db_/, '')

        spinner.succeed(successMessage('Your Prisma Postgres database is ready ✅'))
      } catch (error) {
        spinner.fail(error instanceof Error ? error.message : 'Something went wrong')
        throw error
      }
    }

    if (
      fs.existsSync(path.join(outputDir, 'schema.prisma')) ||
      fs.existsSync(prismaFolder) ||
      fs.existsSync(path.join(prismaFolder, 'schema.prisma'))
    ) {
      if (isPpgCommand) {
        return printPpgInitOutput({
          databaseUrl: prismaPostgresDatabaseUrl!,
          workspaceId: workspaceId!,
          projectId: projectId!,
          environmentId,
          isExistingPrismaProject: true,
        })
      }
    }

    if (fs.existsSync(path.join(outputDir, 'schema.prisma'))) {
      console.log(
        printError(`File ${bold('schema.prisma')} already exists in your project.
        Please try again in a project that is not yet using Prisma.
      `),
      )
      process.exit(1)
    }

    if (fs.existsSync(prismaFolder)) {
      console.log(
        printError(`A folder called ${bold('prisma')} already exists in your project.
        Please try again in a project that is not yet using Prisma.
      `),
      )
      process.exit(1)
    }

    if (fs.existsSync(path.join(prismaFolder, 'schema.prisma'))) {
      console.log(
        printError(`File ${bold('prisma/schema.prisma')} already exists in your project.
        Please try again in a project that is not yet using Prisma.
      `),
      )
      process.exit(1)
    }

    /**
     * Validation successful? Let's create everything!
     */

    if (!fs.existsSync(outputDir)) {
      fs.mkdirSync(outputDir)
    }

    if (!fs.existsSync(prismaFolder)) {
      fs.mkdirSync(prismaFolder)
    }

    const clientOutput = output ?? determineClientOutputPath(prismaFolder)

    writer.write(
      path.join(prismaFolder, 'schema.prisma'),
      generatedSchema ||
        defaultSchema({
          datasourceProvider,
          generatorProvider,
          previewFeatures,
          output: clientOutput,
          withModel: args['--with-model'],
        }),
    )

    const databaseUrl = prismaPostgresDatabaseUrl || url
    const warnings: string[] = []

    writer.write(
      path.join(outputDir, 'prisma.config.ts'),
      defaultConfig({
        prismaFolder,
      }),
    )

    const envPath = path.join(outputDir, '.env')
    if (!fs.existsSync(envPath)) {
      writer.write(envPath, await defaultEnv(databaseUrl, args['--debug']))
    } else {
      const envFile = fs.readFileSync(envPath, { encoding: 'utf8' })
      const config = dotenv.parse(envFile) // will return an object
      if (Object.keys(config).includes('DATABASE_URL')) {
        warnings.push(
          `${yellow('warn')} Prisma would have added DATABASE_URL but it already exists in ${bold(
            path.relative(outputDir, envPath),
          )}.`,
        )
      } else {
        fs.appendFileSync(
          envPath,
          `\n\n` + '# This was inserted by `prisma init`:\n' + (await defaultEnv(databaseUrl, args['--debug'])),
        )
      }
    }

    const gitignorePath = path.join(outputDir, '.gitignore')
    try {
      writer.write(gitignorePath, defaultGitIgnore(), { flag: 'wx' })
    } catch (e) {
      if ((e as NodeJS.ErrnoException).code === 'EEXIST') {
        warnings.push(
          `${yellow(
            'warn',
          )} You already have a ${bold('.gitignore')} file. Don't forget to add ${bold('.env')} in it to not commit any private information.`,
        )
      } else {
        console.error('Failed to write .gitignore file, reason: ', e)
      }
    }

    // Append the generated client to the .gitignore file regardless of whether we created it
    // in the previous step.
    const clientPathRelativeToOutputDir = path.relative(outputDir, path.resolve(prismaFolder, clientOutput))
    try {
      fs.appendFileSync(gitignorePath, `\n/${clientPathRelativeToOutputDir.replaceAll(path.sep, '/')}\n`)
    } catch (e) {
      console.error('Failed to append client path to .gitignore file, reason: ', e)
    }

    const connectExistingDatabaseSteps = `\
  1. Configure your DATABASE_URL in ${green('prisma.config.ts')}
  2. Run ${green(getCommandWithExecutor('prisma db pull'))} to introspect your database.`

    const postgresProviders: ConnectorType[] = ['postgres', 'postgresql', 'prisma+postgres']

    let setupDatabaseSection: string
    if (postgresProviders.includes(datasourceProvider)) {
      setupDatabaseSection = `\
Next, choose how you want to set up your database:

CONNECT EXISTING DATABASE:
${connectExistingDatabaseSteps}

CREATE NEW DATABASE:
  Local: ${green('npx prisma dev')} (runs Postgres locally in your terminal)
  Cloud: ${green('npx create-db')} (creates a free Prisma Postgres database)`
    } else {
      setupDatabaseSection = `\
Next, set up your database:
${connectExistingDatabaseSteps}`
    }

    const defaultOutput = `
Initialized Prisma in your project

${writer.format({
  level: 0,
  printHeadersFromLevel: 1,
  indentSize: 2,
})}
${warnings.length > 0 && logger.should.warn() ? `\n${warnings.join('\n')}\n` : ''}
${setupDatabaseSection}

Then, define your models in ${green('prisma/schema.prisma')} and run ${green(getCommandWithExecutor('prisma migrate dev'))} to apply your schema.

Learn more: ${link('https://pris.ly/getting-started')}
 `

    return isPpgCommand
      ? printPpgInitOutput({
          databaseUrl: prismaPostgresDatabaseUrl!,
          workspaceId: workspaceId!,
          projectId: projectId!,
          environmentId,
        })
      : defaultOutput
  }

  // help message
  public help(error?: string): string | HelpError {
    if (error) {
      return new HelpError(`\n${bold(red(`!`))} ${error}\n${Init.help}`)
    }
    return Init.help
  }
}

// order matters for the error message.
const DATASOURCE_PROVIDERS = [
  'postgresql',
  'mysql',
  'sqlite',
  'sqlserver',
  'mongodb',
  'cockroachdb',
  'prismapostgres',
  'prisma+postgres',
] as const

function assertDatasourceProvider(thing: unknown): asserts thing is ConnectorType {
  if (typeof thing !== 'string' || !DATASOURCE_PROVIDERS.includes(thing as never)) {
    throw new Error(
      `Provider "${thing}" is invalid or not supported. Try again with ${DATASOURCE_PROVIDERS.slice(0, -1)
        .map((p) => `"${p}"`)
        .join(', ')} or "${DATASOURCE_PROVIDERS.at(-1)}".`,
    )
  }
}
